Skip to content

Suppress speaker echo on the microphone channel - #113

Draft
alpha5611331 wants to merge 17 commits into
mainfrom
feat/echo-suppression
Draft

Suppress speaker echo on the microphone channel#113
alpha5611331 wants to merge 17 commits into
mainfrom
feat/echo-suppression

Conversation

@alpha5611331

@alpha5611331 alpha5611331 commented Aug 28, 2026

Copy link
Copy Markdown
Member

Closes #111.

The branch for the whole echo-suppression feature, landing in the order the work has to happen: the measurement first, because every constant in the gate is sized from it, and measuring after the fact means tuning twice.

Landed so far

1. One place for the microphone processing constraints

echoCancellation, noiseSuppression and autoGainControl are stated rather than left on Chromium's implicit defaults. micConstraints() in live-transcription.service.ts is the single spelling, and every getUserMedia in the app goes through it.

This changes no behaviour today - all three already default to true. The point is that they stop moving on their own under a Chromium version bump, and that there is one place to flip them from once the probe says which way they should go. AGC is a named constant because it is the flag most likely to move: it raises gain through quiet passages, which amplifies re-captured interviewer audio on a speaker setup.

The no-device case is an object with no deviceId rather than audio: true, which would have dropped the flags along with it. The flags are plain (advisory) values, not exact, so there is no new OverconstrainedError path.

test/mic-constraints.test.mjs pins it. That is not defensive habit: see the merge section below.

2. A manual probe for the coupling

test/manual/echo-probe.mjs drives a real Electron session and reports, once a second:

    delayMs   corr   prom    erlDb   ref%   mic%   coupled
    -------   ----   ----    -----   ----   ----   -------
        120   0.87   0.71    -18.4     71     74   yes

It also drives the A/B the constraints work exists for:

pnpm exec electron test/manual/echo-probe.mjs --no-aec
pnpm exec electron test/manual/echo-probe.mjs --no-agc
pnpm exec electron test/manual/echo-probe.mjs --no-ns

Manual, like taskbar-probe.mjs: it needs a desktop session, real speakers and someone to play audio into them, so it stays out of test/run.mjs. It is not packaged either - build.files ships only electron-dist, dist and package.json, so the probe's nodeIntegration never reaches a release. It is documented in CLAUDE.md's Headphones section, where the problem it measures is described.

What the merge with main found

main moved 41 commits ahead while this branch was open, and merged clean. It also silently defeated the change, which is the part worth reading.

Two more microphone captures had been added. mock-transcription.service.ts inlined its own copy of the three flags; the settings microphone test opened with audio: true, which drops them entirely - the exact pattern this branch exists to remove. Neither produces a conflict, a type error or a lint warning, because there is nothing wrong with writing a constraint object.

The audio: true one has a user-visible edge of its own: it is the mic test meter, so the level shown while choosing a device was measured through different processing than the session that level is meant to predict.

Both now go through micConstraints(), and test/mic-constraints.test.mjs walks src/ and fails on any capture that opens its own way, naming the file. Verified to fail on exactly the regression that reached main. Only src/ is scanned - the probe varies those flags on purpose.

What building and reviewing the probe found

Every one of these is a case of the tool reporting a confident number that was not true, which is the only failure mode that matters in an instrument the rest of the feature is tuned against.

The correlator is right, including the sign. Injected delays of +120, +300, -150, -250 and 0 ms all come back exact to the frame, and the ERL matches the injected gain (0.1 -> -20.0 dB). The two-sided search is not theoretical: the acoustic path is always mic-after-speaker, but what is measured is arrival order at the worklet, and Chromium's getDisplayMedia loopback path carries its own latency, so on some machines the reference arrives after the echo it explains.

Peak height cannot tell coupling from noise. The search takes the max over ~120 candidate lags, and the max of many correlations is biased upward. Measured: 0.53 on pure silence, 0.57 on two independent bursty signals. A CORR_MIN of 0.5 - which looks perfectly reasonable written down, and is what the design started with - calls both of those coupled. That error runs in the dangerous direction: a false "coupled" on a headphone user is what leads a gate to cut a microphone that was never echoing anything.

Replaced with peak prominence, the peak's height above the median lag. A real echo puts a sharp peak on an otherwise flat surface; unrelated signals produce a surface that is uniformly mediocre with a high maximum and no peak.

And the prominence threshold has less margin than the synthetic numbers implied. Synthetics gave 0.28 unrelated against 0.87-1.13 for echo. A live run in a silent room reached 0.47, against a threshold of 0.5. So the run-level verdict counts coupled reports rather than latching on the first, and reports the fraction either way, saying INCONCLUSIVE rather than guessing.

A mistyped flag was silently ignored. --noaec (a plausible slip for --no-aec) ran with echo cancellation ON and reported a credible number for the configuration you were trying to rule out. Unknown arguments and a non-positive --seconds now exit 2. A non-numeric --seconds previously reached setTimeout as NaN, ending the run before it started, which reads like a headphone result.

And the same failure one layer down. These constraints are advisory, so Chromium may decline one and say so only in getSettings(). Requested and applied were both printed with any disagreement left for the reader to spot - in the tool whose whole job is scoring that A/B, and unlike the typo, not the operator's fault. A mismatch is now called out, and a device that reports nothing back is said to report nothing rather than read as agreement.

getDisplayMedia was unbounded, so a loopback that never resolves left the probe silent with no output. Bounded at 20s, the same as live-transcription.service.ts.

Ways the verdict could still be stated on nothing

A second review pass, all in the same class as the above.

Discarded reports still voted. The counters incremented before the stall check, so a capture that died while coupled kept feeding the headline finding the counters exist to protect. Both health checks now run first.

The frame counter cannot see an unplugged microphone, though its comment claimed it. The worklet is pulled by the destination for the life of the context and zero-pads a missing input by design, so frames keep arriving at 100/s after a track ends while the columns decay quietly toward the noise floor. Ended tracks are now detected and named separately. readyState === 'ended' is the signal and muted deliberately is not - it toggles on ordinary silence on some platforms and would discard most of a legitimately quiet run.

The first report latched the stall flag permanently. lastFrames starts at 0, so a report landing before the graph's first frame looked identical to a dead capture and stamped a "re-run this" warning on a run that then went fine.

A suspended AudioContext produced a silent run with no explanation: no frames, a table of blanks, and a summary reading "no coupling" - the headphone verdict, from a probe that never listened. Resumed if suspended.

"No coupling" could print under a non-zero coupled-estimate count. Estimates run twice a second, reports sample once, so intermittent coupling can enter samples with no report tick landing on it. The headphone verdict now has to clear both counters.

A run with no valid report at all still got a verdict. Every report discarded, or a run shorter than the report interval, reached the summary at zero and printed "no coupling (headphones...)". It now says nothing was measured, and names both causes.

A loopback carrying no audio track is called out at startup rather than left to be inferred from an empty ref% column forty lines later.

And two more, found by running it rather than reading it

A silent run was read as headphones. A run where the loopback reference never carried audio played nothing for the microphone to re-capture, so it measured nothing about coupling - and the verdict called that "no coupling (headphones, or nothing played through them)", offering the two as equal readings while the ref% column had already told them apart. The silent case now says so and says what to do, and the headphone verdict now means what it says: audio played, and the mic did not pick it up.

Every failure exited 0. process.exitCode = 1 followed by app.quit() does not survive - Electron ends the process through its own path and the status comes out 0 - so every failure the probe reported came back to the shell as success. A --device that does not exist printed its error, printed its list of real device names, and exited 0. Both error paths and the window-closed-early path went through it. app.exit carries the code.

And four more, once real audio was driven through the loopback

The coupled path had never actually executed before this. Driving a bursty reference through the speakers ran it, and it found:

A false "coupled" with the microphone silent. An estimate was accepted at correlation 0.62 and prominence 0.61 with mic% at 0 and an ERL of -56 dB. That is not an echo 56 dB down, it is the correlator finding structure in a noise floor, at a lag pinned to the edge of the search window. The thresholds cannot catch it, because the surface really does have a sharp peak - and a false "coupled" on a headphone user is the expensive direction, which is the entire reason prominence exists. An estimate can only describe re-captured audio if the microphone recorded any, so that precondition is now required outright. Real coupling on the same machine runs mic% 24-45 against a floor of 5, and a re-run confirms the verdict still lands at coupled 8/16.

The search window needed a source edit to widen. The summary's answer to a peak at the edge was "widen MIN_LAG_MS/MAX_LAG_MS in renderer.js and re-run". That warning is not exotic: it fired on the first machine measured, whose peak sat at -400, the exact floor of the default. Completing a measurement should not mean editing a renderer file, so the window is now --min-lag= / --max-lag=, validated like the rest, and the warning names the flag and the value to re-run with.

The summary and the table describe different populations. Medians cover accepted estimates only, which run twice per printed row, so the summary reported a median correlation of 0.67 when no printed row exceeded 0.53. Side by side that reads as an error rather than a better-filtered number. Said explicitly now.

The search window could be widened past what the correlator can search. Making the window settable created a new way to get the silent wrong answer: a lag further from zero than the 4 s history can cover leaves every candidate below the minimum overlap, so no estimate is produced at all and the summary reports "no correlated frames". Rejected now, with the arithmetic, and the widen-the-window suggestion is clamped so it can never name a window the probe would then refuse.

INCONCLUSIVE gave advice that did not fit. It said "re-run with audio playing for the whole duration" to a run whose ref% sat between 72 and 85 the entire time, sending someone to redo what they had already done correctly. A reference that was solid throughout means the coupling is marginal on that machine, which is a finding rather than a mistake, and the two cases now get their own remedy.

First numbers off a real machine

Not the A/B the checklist wants - one machine, and a synthetic bursty reference rather than a recorded interview - but the rig ran, and two things came out of it that matter to the plan.

applied: aec=false comes back from the device under --no-aec, which is the constraints change verified end to end rather than assumed.

run config erlDb median coupled
1 default -36.0 2/15
2 default -38.8 8/16
3 --no-aec -43.1 9/16

Two runs of the same configuration differ by 2.8 dB while the configurations differ by 4.3 dB. So "run each twice and compare erlDb", which is what this PR's own instructions said, cannot separate a real effect from run-to-run spread, and would have someone record a difference that is mostly noise in the one number the constraints decision gets made on. The header now says to establish the unchanged configuration's spread first and discard any difference smaller than it.

The delay is negative on this machine (median -280 ms), which is the signed-lag case the two-sided search was built for, confirmed on hardware rather than argued from first principles. And the default window was genuinely too narrow for it.

For the person running it

The window could be closed mid-run, ending the process at exit 0 with no summary and nothing said - indistinguishable, in a scrollback, from a run that finished. A failed window load rejected into nothing, leaving the window at "starting..." waiting for a run that would not begin. The likeliest error in this tool is a mistyped --device, whose message is a list of the device names that do exist, and it was printed under ten frames of Electron internals; failures the operator can fix now print their message alone. The run asks a person to play audio and stay quiet for 45 seconds while the only thing moving is a console table, so the window counts down and says when the summary is ready.

Notes recorded in the code rather than acted on

Pacing. The meter paces its cross-correlation on performance.now(). Fine for a live capture at 100 frames/s, but it makes the class untestable from synthetic input, because a test loop feeds thousands of frames in a few milliseconds and no interval ever elapses. echo-gate.ts needs unit tests, so it should pace on a frame counter instead.

Overlap bias. The correlation overlap shrinks as |lag| grows (400 frames at lag 0, 320 at +80), so edge lags are estimated from ~20% less audio and the peak leans very slightly outward - on the order of 0.02, against a noise floor that has measured 0.47-0.57, and it cannot reach the "peak sits at the edge" warning, which only prints for runs that already have coupled estimates. Correcting it would move every number the gate is about to be sized from, on judgement rather than on data, so it is written down where whoever picks the window will read it.

Still to come on this branch

  • Measure - A/B echoCancellation and autoGainControl on 2-3 machines; record delayMs (with sign), correlation, prominence and erlDb. One machine's numbers are above, against a synthetic reference; they are a shakedown of the rig, not the result. Note the run-to-run spread before designing the rest.
  • Layer 1 - one AudioContext, a two-input worklet, and a delay-aligned energy gate on ch_1 (audio-capture-graph.ts + a dependency-free echo-gate.ts), plus the audio-device-switch.test.mjs rewrite.
  • Layer 2 - cross-channel transcript dedup in ingest(), gated on time overlap (echo-dedup.ts).
  • Docs - hero docs and an entry on speaker vs headphone capture in src/renderer/pages/documentation/index.tsx (the old documentation-dialog.tsx this checklist named was renamed in Upgrade the client app UX: launch hub, first-run setup, Account/Configuration split, hint-only default #125, and the page currently has no headphone entry at all). CLAUDE.md is done: the probe and the constraints rule are documented in Headphones, and the stale "deduplicates overlapping segments" claim is corrected - nothing deduplicates, which is the gap that claim was hiding.

Verification

  • eslint, tsc -b, tsc -p tsconfig.electron.json, vite build and pnpm test:main all green - the same set CI runs.
  • test/mic-constraints.test.mjs verified against a deliberate regression: reverting the mic test to audio: true fails two checks and names the file.
  • Probe run end to end on Windows 11 repeatedly: acquires both streams, reports the applied constraints back, runs the correlator, prints the summary. With nothing playing it correctly reports NOTHING PLAYED rather than guessing at headphones, and exits 0.
  • Argument guards exercised (--bogus, --noaec, --seconds=abc, --seconds=0), all exit 2.
  • Error path exercised: a --device that does not exist prints its message and the real device labels, with no stack trace, and exits 1 with the output intact through a pipe.
  • Coupled path exercised against real audio played through the speakers: reaches coupled (speakers) at 8/16, correctly reports NOTHING PLAYED when silent, and the --min-lag/--max-lag guards reject a non-numeric or inverted window with exit 2.
  • Correlator verified against synthetic signals of known delay, driving the real file rather than a copy. That harness is not committed - the equivalent coverage belongs in test/echo-gate.test.mjs once the gate exists and is testable, which is what the pacing note above is about.
  • Not done: the actual A/B across machines. It needs a person playing a recorded interview through speakers and cannot run from CI.

Supersedes #112, which GitHub closed when the branch was renamed.

alpha5611331 and others added 3 commits August 27, 2026 20:54
Both getUserMedia call sites passed only a deviceId, so echoCancellation,
noiseSuppression and autoGainControl ran on whatever Chromium currently
defaults to. All three default to true today, so this changes no behaviour -
it stops the behaviour changing on its own under a Chromium version bump, and
gives the echo work one place to flip them from.

AGC is split out as a named constant because it is the flag most likely to
move: it raises gain through quiet passages, which amplifies re-captured
interviewer audio on a speaker setup.

The no-device case is an object with no deviceId rather than `audio: true`,
which would have dropped the flags along with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Measures how much of the interviewer's audio the microphone re-captures when
the candidate is on speakers. Reports three numbers per machine: the signed
arrival-order delay between the two channels, the correlation peak at that
lag, and the echo return loss.

The sign matters and is the reason the search window is two-sided. The
acoustic path is always mic-after-speaker, but what is measured here is
arrival order at the worklet, and Chromium's getDisplayMedia loopback path
carries its own latency - so on a machine where it is the slower of the two,
the reference arrives after the echo it explains.

The window searched is wider than any gate would ship with, so a peak sitting
at the edge is distinguishable from a window that is too small; the summary
warns when that happens.

An estimate is only accepted while the reference is actually active. A silent
run reached a correlation of 0.53 - two noise floors correlate - so a peak
height alone cannot tell coupling from silence.

Manual, like taskbar-probe.mjs: it needs a desktop session, real speakers and
someone to play audio into them, so it stays out of test/run.mjs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Verifying the correlator against synthetic signals turned up the more
important defect. Delay recovery is exact, including the sign: +120, +300,
-150, -250 and 0 ms all come back to the frame, and the ERL matches the
injected gain. But the coupling verdict was wrong in the dangerous direction.

The search takes the max over ~120 candidate lags, and the max of many
correlations is biased upward, so unrelated signals score far higher than
intuition suggests: 0.53 on pure silence, 0.57 on two independent bursty
signals. A CORR_MIN of 0.5 calls both of those coupled, and a false "coupled"
on a headphone user is what would lead a gate to cut a microphone that was
never echoing anything.

Prominence - the peak's height above the median lag - separates them cleanly:
0.28 for the unrelated pair against 0.87-1.13 for a real echo. Both ends of
that gap are optimistic, so the threshold is a starting point to be re-derived
from real runs, and the raw numbers are printed every second regardless.

Also: reject unknown arguments and a non-positive --seconds. A mistyped
--noaec was silently ignored, which runs with echo cancellation ON and reports
a plausible number for the configuration you were trying to rule out; a
non-numeric --seconds reached setTimeout as NaN and ended the run before it
started, which reads like a headphone result.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@gitar-bot

gitar-bot Bot commented Aug 28, 2026

Copy link
Copy Markdown

Gitar is working

Gitar

Review of the probe turned up four ways it could report a confident number
that was not true.

The run-level verdict latched on a single coupled report, so one spurious
second decided the headline finding for the whole run. That matters more after
a live run reached a prominence of 0.47 in a silent room, against a threshold
of 0.5 - the synthetic separation of 0.28 was optimistic, and a quiet room
crosses that line occasionally. The verdict now counts coupled reports and
refuses to call a run that cannot show several, reporting the fraction either
way and saying INCONCLUSIVE rather than guessing.

A stalled capture was invisible. push() simply stops being called, the report
timer keeps firing, and the same numbers print every second looking exactly
like a steady measurement. Frames are now counted and a report with no new
frames says so, with a warning in the summary.

getDisplayMedia was unbounded, so a loopback that never resolves left the
probe sitting silently with no output. Bounded at 20s, the same as
live-transcription.service.ts bounds it.

And CORR_MIN now says explicitly that it is a floor rather than the
discriminator - on its own it is the threshold already shown to be useless.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@stackoracle
stackoracle marked this pull request as draft August 28, 2026 01:51
alpha5611331 and others added 13 commits August 28, 2026 02:53
Review of the branch, plus the merge of main, turned up six ways the work
reported something that was not true.

The merge is the substantive one. main added two more microphone captures
while this branch was open: mock-transcription.service.ts, which inlines its
own copy of the three processing flags, and the settings microphone test,
which opens with `audio: true` and so takes whatever Chromium defaults to.
Both textually merged clean and both defeat the point of the change - "one
place to flip them from" is not one place if three callers spell it out
themselves. micConstraints is exported and used by all three. The test meter
case has a second edge: opened as `true`, the level shown while picking a
device is measured through different processing than the session it exists to
predict.

In the probe:

The stall guard counted the report it was about to refuse to print. `coupled`
on a stalled report is the verdict of an estimate that ran against audio which
is no longer arriving, so a capture that died while coupled kept voting on the
headline finding - the exact thing the counters were added to stop. Both health
checks now run before the counters.

The frame counter cannot see an unplugged microphone, though the comment
claimed it. The worklet is pulled by the destination for the life of the
context and zero-pads a missing input by design, so frames keep arriving at
100/s after a track ends while the columns decay quietly toward the noise
floor. Ended tracks are reported separately and named. `muted` is deliberately
not used - it toggles on ordinary silence on some platforms and would discard
most of a legitimately quiet run.

The first report latched `stalled` permanently. lastFrames starts at 0, so a
report that lands before the graph produces its first frame looks exactly like
a capture that died, and put a "re-run this" warning on the summary of a run
that then went fine. Before the first frame is now distinguished from after the
last one.

A suspended AudioContext produced a silent run with no explanation: no frames,
a table of blanks, and a summary reading "no coupling" - the headphone verdict,
from a probe that never listened. Resumed if suspended.

"No coupling" could print directly underneath a non-zero count of accepted
coupled estimates. Estimates run twice a second and reports are sampled once,
so intermittent coupling can enter `samples` without a report tick landing on
it. The headphone verdict now has to clear both counters.

Also: a loopback with no audio track is called out at startup rather than left
to be inferred from an empty ref% column, `--device` lists the labels it did
not match (an exact match against long parenthesised OS strings is the whole
difficulty), `--no-ns` is documented alongside the other two flags, and the
stall warning no longer prints a mangled duplicate of its own second line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…on running it

A test for the invariant the merge already broke once. `main` grew two more
microphone captures while this branch was open, one with the three processing
flags copied out and one opening with `audio: true`, and both merged clean -
there is no conflict, no type error and no lint warning in adding a second
spelling of a constraint object, which is why a checker has to be the thing
that notices. `mic-constraints.test.mjs` walks src/ and fails on any capture
that does not open through micConstraints, naming the file. It fails, verified,
on exactly the regression that reached main. Only src/ is scanned: the probe
varies those flags on purpose, which is its job.

Then the probe, from the point of view of someone actually running it.

Closing the window mid-run ended the process at exit 0 with no summary and
nothing said - indistinguishable, in a scrollback, from a run that finished.
It now says what happened and exits non-zero.

A failed window load rejected into nothing: the window sat at "starting...",
no report ever arrived, and the probe waited for a run that would not begin.

The likeliest failure in this tool is a mistyped --device, whose message is a
list of the device names that do exist - and it was printed under ten frames of
Electron internals. Failures the operator can fix are marked, and print their
message alone. The loopback timeout is one of them, and now says what to check
rather than just that it timed out.

The run asks a person to play audio and stay quiet for 45 seconds while the
only thing moving is a console table. The window counts down the seconds left,
and says when the summary is ready.

Also recorded, not corrected: the correlation overlap shrinks as |lag| grows,
so lags at the edges of the search are estimated from ~20% less audio and the
peak leans very slightly outward. It is ~0.02 against a noise floor that has
measured 0.47-0.57, and it cannot reach the "peak sits at the edge" warning,
which only prints for runs that already have coupled estimates. Correcting it
would move every number the gate is about to be sized from, on judgement rather
than on data, so it is written down where whoever picks the window will read it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A run where every report was discarded - all stalled, all on a dead capture -
or one too short to produce a single report reached the summary with
totalReports at zero and printed "no coupling (headphones, or nothing played
through them)". That is the worst version of the failure the rest of this
summary is built to avoid: the headphone verdict, stated confidently, from a
probe that took no valid reading at all. It now says so and names both causes.

And the probe was undiscoverable. `taskbar-probe.mjs` is documented in the
section it belongs to; this one was in no section at all, so the next person to
wonder how much of the interviewer the microphone re-captures had no way to
find the tool that answers it. The Headphones section now carries it, together
with the constraints rule and the test that pins it.

CLAUDE.md is edited by hand rather than through prettier: running prettier on it
rewrites 29 unrelated emphasis markers, which is the churn the file's own
Commands section warns about. This change is purely additive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The probe rejects a mistyped `--noaec` because a silently dropped flag runs
with echo cancellation ON and reports a plausible number for the configuration
you were trying to rule out. The same thing happens one layer down and was not
handled: these constraints are advisory, so Chromium is free to decline them
and say so only in getSettings(). Requested and applied were both printed and
any disagreement left for the reader to spot, in a tool whose entire job is
scoring that A/B - and unlike the typo, this one is not the operator's fault.

Now called out. A device that reports nothing back is said to report nothing,
rather than being read as agreement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…on-zero

Both found by running the probe rather than reading it.

The first is the same class as everything else in this summary. A run where the
loopback reference never carried audio played nothing for the microphone to
re-capture, so it measured nothing about coupling - and the verdict called that
"no coupling (headphones, or nothing played through them)", offering the two as
equal readings while the ref% column had already told them apart. A run only
measures coupling if something was coupling-capable in the first place. The
silent case now says so and says what to do; the headphone verdict now means
what it says, audio played and the mic did not pick it up.

The second is worse for being invisible. `process.exitCode = 1` followed by
`app.quit()` does not survive - Electron ends the process through its own path
and the status comes out 0 - so every failure the probe reported came back as
success to the shell. A `--device` that does not exist printed its error, its
list of real device names, and exited 0. Both the error paths and the
window-closed-early path went through it. `app.exit` carries the code.

Verified end to end on Windows 11: a run exits 0, a bad --device exits 1 with
its message intact through a pipe, and the four argument guards still exit 2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ing the reader to edit source

Four findings, all from running the probe against real audio rather than
reading it. The coupled path had never actually executed before this.

**A false "coupled" with the microphone silent.** A run accepted an estimate at
correlation 0.62 and prominence 0.61 with mic% at 0 and an ERL of -56 dB. That
is not an echo 56 dB down, it is the correlator finding structure in a noise
floor, at a lag pinned to the edge of the search window. The thresholds cannot
catch it on their own, because the surface really does have a sharp peak - and
a false "coupled" on a HEADPHONE user is the expensive direction, which is the
entire reason prominence exists. An estimate can only describe re-captured
audio if the microphone recorded any, so that precondition is now required
outright. Real coupling on the same machine runs mic% 24-45 against a floor of
5, and a re-run confirms the verdict still lands at coupled 8/16.

**The search window needed a source edit.** The summary's answer to a peak at
the edge was "widen MIN_LAG_MS/MAX_LAG_MS in renderer.js and re-run". That
warning is not exotic: it fired on the first machine measured, whose peak sat
at -400, the exact floor of the default. Completing a measurement should not
mean editing a renderer file, so the window is `--min-lag=` / `--max-lag=`,
validated like the rest, and the warning now names the flag and the value to
re-run with. Re-run at --min-lag=-1200 the peak lands at -280 and the warning
correctly stops firing.

**The summary and the table describe different populations.** Medians cover
accepted estimates only, which run twice per printed row, so the summary
reported a median correlation of 0.67 when no printed row exceeded 0.53. Read
side by side that looks like an error rather than a better-filtered number.
Said explicitly now.

**INCONCLUSIVE gave advice that did not fit.** It said "re-run with audio
playing for the whole duration" to a run whose ref% sat between 72 and 85 the
entire time - sending someone to redo the thing they had already done
correctly. A reference that was solid throughout means the coupling is marginal
on that machine, which is a finding, not a mistake, and the two cases now get
their own remedy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Run each twice and compare erlDb" is not enough method, and the first machine
measured shows why: two 16 s runs of the same unchanged configuration came back
-36.0 and -38.8, a spread of 2.8 dB, while the aec on/off pair differed by
4.3 dB. Two runs cannot separate a real effect from that, and the instruction
as written would have someone record a difference that is mostly noise - in the
one number the constraints decision gets made on.

The header now says to establish the unchanged configuration's spread first and
to discard any difference smaller than it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Making --min-lag/--max-lag settable opened a new way to get the silent wrong
answer this branch keeps closing. The correlator holds 4 s of history and needs
500 ms of overlap at every candidate lag, so a bound beyond +/-3500 ms leaves
correlateAt below its minimum at every lag: it returns null for all of them, no
estimate is ever produced, and the summary reports "no correlated frames" - the
headphone answer, from a window that was too wide to search rather than a mic
that heard nothing.

Checked in the renderer, where the history and overlap numbers are defined, and
raised as a ProbeError so it prints the arithmetic and no stack. --min-lag=-5000
now explains itself and exits 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The edge warning suggests a wider search, and the previous commit taught the
probe to refuse a window beyond what the correlator can search. On a window
already near that limit the two disagreed: the summary would name a --min-lag
the next run exits 1 on.

The suggestion is clamped to the limit now, and when there is no room left to
widen it says that instead - the delay is a lower bound at that point, and
resolving it needs a longer history rather than a wider window. The limit
travels with the summary from the renderer, where it is derived, rather than
being restated here and left to drift.

Checked across the five cases: peak at the floor and at the ceiling of the
default window, peak at each end of a window already at the limit, and a peak
mid-window that warns about nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"No correlated frames. Either this is a headphone setup (the good case), or no
audio was playing" was written when that line was the whole answer. It is not
any more: the verdict beneath it now distinguishes NOTHING MEASURED, NOTHING
PLAYED, no coupling and INCONCLUSIVE, using the ref% history and the report
counts. So the line offered "the good case" as a live possibility directly
above a verdict saying nothing had been measured at all - the same unearned
confidence this branch has been removing everywhere else, in the one place that
used to be entitled to it.

It states the fact and stops, in the same label/value shape as the populated
branch. Interpreting is the verdict's job.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CLAUDE.md said `ingest()` "deduplicates overlapping segments". Nothing does.
`mergeAdjacentTranscripts` concatenates consecutive blocks from the SAME
speaker inside TRANSCRIPT_INTER_TRANSCRIPT_GAP_MS, and no code anywhere
compares the two channels against each other - grepped for it to be sure.

Worth more than a wording fix, because the file already contradicted itself:
the Headphones section a few hundred lines down says the transcript duplicates
on speakers and that suppressing it is what #111 is for. A reader who took line
63 at face value would conclude the echo duplication was already handled, which
is the exact opposite of the problem this branch exists to measure, and would
have no reason to look further.

Listed on this PR's own checklist and cheap to settle now that the code was
being read anyway.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@alpha5611331
alpha5611331 marked this pull request as ready for review September 7, 2026 12:39
@alpha5611331
alpha5611331 marked this pull request as draft September 7, 2026 16:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Speaker echo on the mic channel silently suppresses live suggestions

1 participant